#
# Copyright (c) 2023 supercell
#
# SPDX-License-Identifier: BSD-3-Clause
#
require "./footnote_ref_syntax"

module Luce
  # A helper class holds params of link context.
  # Footnote creation needs other info in `try_create_reference_link`.
  class LinkContext
    getter parser : InlineParser
    getter opener : SimpleDelimiter
    getter get_children : Proc(Array(Node))

    def initialize(@parser : InlineParser, @opener : SimpleDelimiter, @get_children : Proc(Array(Node)))
    end
  end

  # Matches links like `[blah][label]` and `[blah](url)`.
  class LinkSyntax < DelimiterSyntax
    @entirely_white_space_pattern = Regex.new(%q(^\s*$))

    getter link_resolver : Resolver

    def initialize(link_resolver : Resolver? = nil, pattern : String = %q(\[), start_character : Int32 = Charcode::LBRACKET)
      if link_resolver.nil?
        @link_resolver = Proc(String, String?, Node?).new { |_, _| nil }
      else
        @link_resolver = link_resolver
      end
      super(pattern, start_character: start_character)
    end

    def close(parser : InlineParser,
              opener : SimpleDelimiter,
              closer : DelimiterTypes?,
              get_children : Proc(Array(Node)),
              tag : String? = nil) : Array(Node)?
      context = LinkContext.new(parser, opener, get_children)
      text = parser.source[opener.end_pos...parser.pos]
      # The current character is the `]` that closed the link text.
      # Examine the next character, to determine what type of link we
      # might have (a '(' means a possible inline linke; otherwise, a
      # possible reference link).
      if parser.pos + 1 >= parser.source.size
        # The ']' is at the end of the document, but this may still be
        # a valid shortcut reference link
        return try_create_reference_link(context, text)
      end

      # Peek at the next character; don't advance, so as to avoid later
      # stepping backwards.
      char = parser.char_at(parser.pos + 1)

      if char == Charcode::LPAREN
        # Maybe an inline link, like `[text](destination)`
        parser.advance 1
        left_paren_index = parser.pos
        inline_link = parse_inline_link(parser)
        if !inline_link.nil?
          return [
            try_create_inline_link(
              parser,
              inline_link,
              get_children: get_children
            ),
          ] of Node
        end
        # At this point, we've matched `[...](` but that `(` did not pan
        # out to be an inline link. We must now check if `[...]` is
        # simply a shortcut reference link.

        # Reset the parser position
        parser.pos = left_paren_index
        parser.advance -1
        return try_create_reference_link(context, text)
      end

      if char == Charcode::LBRACKET
        parser.advance 1
        # At this point, we've matched `[...][`. Maybe a *full*
        # reference link, like `[foo][bar]` or a *collapsed* reference
        # link, like `[foo][]`.
        if parser.pos + 1 < parser.source.size &&
           parser.char_at(parser.pos + 1) == Charcode::RBRACKET
          # That opening '[' is not actually part of the link. Maybe a
          # *shortcut* reference link (followed by a '[').
          parser.advance 1
          return try_create_reference_link(context, text)
        end
        label = parse_reference_link_label(parser)
        if !label.nil?
          return try_create_reference_link(context, label, secondary: true)
        end
        return nil
      end

      # The link text (inside `[...]`) was not followed with an opening
      # `(` nor an opening `[`. Perhaps just a simple shortcut
      # reference link (`[...]`).
      try_create_reference_link(context, text)
    end

    private def resolve_reference_link(
      label : String,
      link_references : Hash(String, LinkReference),
      get_children : Proc(Array(Node))
    ) : Node?
      link_reference = link_references[Luce.normalize_link_label(label)]?
      if link_reference.nil?
        # This link has no reference definition. But we allow people
        # to specify a custom resolver function (`link_resolver`) that
        # may choose to handle this. Otherwise, it's just treated as
        # plain text.

        # Normally, label text does not get parsed as inline Markdown.
        # However, for the benefit of the link resolver, we need to at
        # least escape brackets, so that, e.g. a link resolver can
        # receive `[\[\]]` as `[]`.
        resolved = @link_resolver.call(label
          .gsub(%q(\\), %q(\))
          .gsub(%q(\[), "[")
          .gsub(%q(\]), "]"), nil
        )
        unless resolved.nil?
          get_children.call
        end
        resolved
      else
        create_node(link_reference.destination, link_reference.title,
          get_children: get_children)
      end
    end

    # Create the node represented by a Markdown link
    private def create_node(destination : String, title : String?,
                            get_children : Proc(Array(Node))) : Node
      children = get_children.call
      element = Element.new("a", children)
      element.attributes["href"] =
        Luce.normalize_link_destination(Luce.escape_punctuation(destination))
      if !title.nil? && !title.empty?
        element.attributes["title"] =
          Luce.normalize_link_title(Luce.escape_punctuation(title))
      end
      element
    end

    # Tries to create a reference link node
    #
    # Returns the nodes if it was successfully created, `nil` otherwise.
    private def try_create_reference_link(
      context : LinkContext,
      label : String,
      secondary : Bool = false
    ) : Array(Node)?
      parser = context.parser
      get_children = context.get_children
      link = resolve_reference_link(
        label,
        parser.document.link_references,
        get_children: get_children
      )
      return [link] of Node unless link.nil?
      FootnoteRefSyntax.try_create_footnote_link(context, label, secondary: secondary)
    end

    # Tries to create an inline link node
    private def try_create_inline_link(
      parser : InlineParser,
      link : InlineLink,
      get_children : Proc(Array(Node))
    ) : Node
      create_node(link.destination, link.title, get_children: get_children)
    end

    # Parse a reference link label at the current position
    #
    # Specifically, `parser.pos` is expected to be pointing at the `[`
    # which opens the link label.
    #
    # Returns the label if it could be parsed, or `nil` if not.
    private def parse_reference_link_label(parser : InlineParser) : String?
      # Walk past the opening `[`
      parser.advance 1
      return nil if parser.done?

      buffer = String::Builder.new
      loop do
        char = parser.char_at(parser.pos)
        if char == Charcode::BACKSLASH
          parser.advance 1
          next_char = parser.char_at(parser.pos)
          if next_char != Charcode::BACKSLASH && next_char != Charcode::RBRACKET
            buffer << char.chr
          end
          buffer << next_char.chr
        elsif char == Charcode::LBRACKET
          return nil
        elsif char == Charcode::RBRACKET
          break
        else
          buffer << char.chr
        end
        parser.advance 1
        return nil if parser.done?
        # TODO: only check 999 characters, for performance reasons?
      end

      label = buffer.to_s

      # A link label must contain at least one non-whitespace character.
      return nil if @entirely_white_space_pattern.matches? label

      label
    end

    # Parse an inline `InlineLink` at the current position.
    #
    # At this point, we have parsed a link's (or image's) opening `[`,
    # and then a matching closing `]`, and `parser.pos` is pointing at
    # an opening `(`. This method will the attempt to parse a link
    # destination wrapped in `<>`, such as `(<http://url>)`, or a bare
    # link destination, such as `(http://url)`, or a link destination
    # with a title, such as `(http://url "title")`.
    #
    # Returns the `InlineLink` if one was parsed, or `nil` if not.
    private def parse_inline_link(parser : InlineParser) : InlineLink?
      # Start walking to the character just after the opening `(`
      parser.advance(1)

      move_through_whitespace(parser)
      return nil if parser.done? # EOF. Not a link.

      if parser.char_at(parser.pos) == Charcode::LT
        # Maybe a `<...>`-enclosed link destination.
        parse_inline_bracketed_link(parser)
      else
        parse_inline_bare_destination_link(parser)
      end
    end

    # Parse an inline link with a bracketed destination (a destination
    # wrapped in `<...>`).
    #
    # The current position of the parser must be the first character of
    # the destination.
    #
    # Returns the link if it was successfully created, `nil` otherwise.
    private def parse_inline_bracketed_link(parser : InlineParser) : InlineLink?
      parser.advance 1

      buffer = String::Builder.new
      loop do
        char : Int32 = parser.char_at(parser.pos)
        if char == Charcode::BACKSLASH
          parser.advance 1
          next_char = parser.char_at(parser.pos)
          # TODO: Follow the backslash spec better here.
          # https://spec.commonmark.org/0.30/#backslash-escapes
          if next_char != Charcode::BACKSLASH && next_char != Charcode::GT
            buffer << char.chr
          end
          buffer << next_char.chr
        elsif char == Charcode::LF || char == Charcode::CR || char == Charcode::FF
          # Not a link (no line breaks allowed within `<...>`).
          return nil
        elsif char == Charcode::SPACE
          buffer << "%20"
        elsif char == Charcode::GT
          break
        else
          buffer << char.chr
        end
        parser.advance 1
        return nil if parser.done?
      end
      destination = buffer.to_s

      parser.advance 1
      char : Int32 = parser.char_at(parser.pos)
      if char == Charcode::SPACE || char == Charcode::LF || Charcode::CR || char == Charcode::FF
        title = parse_title(parser)
        if title.nil? && (parser.done? || parser.char_at(parser.pos) != Charcode::RPAREN)
          # This looked like an inline link, until we found the space
          # followed by mysterious characters; no longer a link.
          return nil
        end
        InlineLink.new(destination, title: title)
      elsif char == Charcode::RPAREN
        InlineLink.new(destination)
      else
        # We parsed something like `[foo](<url>X`. Not a link.
        nil
      end
    end

    # Parse an inline link with a "bare" destination (a destination
    # _not_ wrapped in `<...>`).
    #
    # The current position of the parser must be the first character of
    # the destination.
    #
    # Returns the link if it was successfully created, `nil` otherwise.
    private def parse_inline_bare_destination_link(parser : InlineParser) : InlineLink?
      # According to CommonMark:
      # https://spec.commonmark.org/0.30/#link-destination
      #
      # > A link destination consists of [...] a nonempty sequence of
      # > characters [...], and includes parentheses only if (a) they
      # > are backslash-escaped or (b) they are part of a balanced pair
      # > or unescaped parentheses.
      #
      # We need to count the open parens. We start with 1 for the paren
      # that opened our destination.
      paren_count = 1
      buffer = String::Builder.new

      loop do
        char = parser.char_at(parser.pos)
        case char
        when Charcode::BACKSLASH
          parser.advance 1
          return nil if parser.done? # EOF. Not a link.
          next_char = parser.char_at(parser.pos)
          # Parentheses may be escaped.
          #
          # https://spec.commonmark.org/0.30/#example-494
          if next_char != Charcode::BACKSLASH && next_char != Charcode::LPAREN && next_char != Charcode::RPAREN
            buffer << char.chr
          end
          buffer << next_char.chr
        when Charcode::SPACE, Charcode::LF, Charcode::CR, Charcode::FF
          destination = buffer.to_s
          title = parse_title(parser)
          if title.nil? && (parser.done? || parser.char_at(parser.pos) != Charcode::RPAREN)
            # This looked like an inline link, until we found this space
            # followed by mystery characters; no longer a link.
            return nil
          end
          # `parse_title` made sure the title was followed by a closing
          # `)` (but it's up to the code here to examine the balance of
          # parentheses)
          paren_count -= 1
          return InlineLink.new(destination, title: title) if paren_count == 0
        when Charcode::LPAREN
          paren_count += 1
          buffer << char.chr
        when Charcode::RPAREN
          paren_count -= 1
          if paren_count == 0
            destination = buffer.to_s
            return InlineLink.new(destination)
          end
          buffer << char.chr
        else
          buffer << char.chr
        end
        parser.advance 1
        return nil if parser.done? # EOF. Not a link.
      end
    end

    # Walk the parser forward through any whitespace.
    private def move_through_whitespace(parser : InlineParser) : Nil
      until parser.done?
        char : Int32 = parser.char_at(parser.pos)
        if char != Charcode::SPACE &&
           char != Charcode::TAB &&
           char != Charcode::LF &&
           char != Charcode::VT &&
           char != Charcode::CR &&
           char != Charcode::FF
          return
        end
        parser.advance 1
      end
    end

    # Parses a link title in *parser* at it's current position.
    #
    # The parser's current position should be a whitespace character
    # that followed a link destination.
    #
    # Returns the title if it was successfully parsed, `nil` otherwise.
    private def parse_title(parser : InlineParser) : String?
      move_through_whitespace(parser)
      return nil if parser.done?

      # The whitespace should be followed by a title delimiter
      delimiter = parser.char_at(parser.pos)
      if delimiter != Charcode::APOSTROPHE &&
         delimiter != Charcode::QUOTE &&
         delimiter != Charcode::LPAREN
        return nil
      end

      close_delimiter = delimiter == Charcode::LPAREN ? Charcode::RPAREN : delimiter
      parser.advance 1

      # Now we look for an un-escaped closing delimiter.
      buffer = String::Builder.new
      loop do
        char = parser.char_at(parser.pos)
        if char == Charcode::BACKSLASH
          parser.advance 1
          next_char = parser.char_at(parser.pos)
          if next_char != Charcode::BACKSLASH && next_char != close_delimiter
            buffer << char.chr
          end
          buffer << next_char.chr
        elsif char == close_delimiter
          break
        else
          buffer << char.chr
        end
        parser.advance 1
        return nil if parser.done?
      end
      title = buffer.to_s

      # Advance past the closing delimiter
      parser.advance 1
      return nil if parser.done?
      move_through_whitespace(parser)
      return nil if parser.done?
      return nil if parser.char_at(parser.pos) != Charcode::RPAREN
      title
    end
  end

  class InlineLink
    getter destination : String
    getter title : String?

    def initialize(@destination : String, @title : String? = nil)
    end
  end
end
